feat: wanted→offer matching, shown after posting#953
Merged
Conversation
✅ Deploy Preview for golden-caramel-d2c3a7 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
edwh
force-pushed
the
feature/wanted-offer-match
branch
from
July 4, 2026 21:05
e930c53 to
50b6a4b
Compare
edwh
force-pushed
the
feature/wanted-offer-match
branch
from
July 5, 2026 17:36
dfd2b96 to
387e215
Compare
…urement - GET /message/:id/similar: stored-embedding cosine, same-type, excludes source+own-author, MinSimilarScore, reach-filtered (shared ReachBlockedSet helper, also used by the browse read path), FEATURE_SIMILAR_POSTS killswitch. - Source-tagged impressions via MarkSeen (first-touch), threaded through the FE. - SimilarPosts.vue: lazy strip on the message page reusing MessageSummary, 10% userid holdout, >=3-results gate, one source-tagged impression per view. - Recommendations sysadmin tab: per-source impressions/clicks/CTR/attributed replies (7-day window) + holdout-vs-shown reply-rate comparison.
…shing message page)
The v-observe-visibility binding used {callback, once, intersection} but the
plugin expects {callback, options}. With no 'options' key it read
options.observeFullElement off undefined at beforeMount, throwing
'Cannot read properties of undefined (reading observeFullElement)' and turning
every message page into a 500 error page (caught by Playwright, not vitest which
stubs the directive). Match OurMessage.vue's proven shape; this plugin has no
'once' so guard re-entry with a loaded flag; add .catch() to the fire-and-forget
markSeen (review finding).
- GET /message/matches: embeds the wanted's item text, finds Offers within ~15km of the poster's chosen location, reach-filtered against that location (shared ReachBlockedSet), excludes own posts, FEATURE_WANTED_MATCH killswitch. - WantedMatches.vue: async, non-blocking panel under the postcode chooser on the desktop and mobile compose steps; reuses MessageSummary; opens each match in a new tab so the draft is preserved; dismissable. Impressions tagged wanted_match (already in the recommendations funnel). - Every failure path (off / no location / no match / sidecar down) hides the panel; it can never break or block posting.
Addresses the Phase 3 review: a monotonic load token discards an out-of-order response from a superseded query/location, and the fire-and-forget markSeen now .catch()es so a non-401 rejection never surfaces as an unhandled promise.
Per review: don't distract the poster mid-compose. Remove the WantedMatches panel from the find whereami step (desktop + mobile) and instead show it on the myposts landing page the poster is sent to right after posting - but only for a just-posted WANTED (derived from the posted item + location in history state). Updates the dismiss copy for the post-success context and adds myposts tests covering the WANTED-shows / OFFER-hides cases.
edwh
force-pushed
the
feature/wanted-offer-match
branch
from
July 12, 2026 17:29
387e215 to
62ec367
Compare
The panel could never render after a real WANTED post. freegleIt() only
pushed the posted ids into history state for Offers:
if (type === 'Offer' && res.id) { params.ids.push(res.id) }
so a real Wanted landed on myposts with {ids: [], type: 'Wanted'}, and the
panel gates on `type === 'Wanted' && ids.length` - unsatisfiable. The gate
was incidental rather than defensive: myposts is the only consumer of
history.state.ids, and both existing consumers (the donation ask and the
"viewed after Offer" event) gate on type === 'Offer' themselves, so
populating ids for both types affects nothing else.
The component spec passed because it stubbed {type:'Wanted', ids:[7]} - a
state the app never produced. Added tests on freegleIt itself asserting the
ids reach myposts for both types; they fail against the old gate.
Also: the cards showed no text at all on desktop. MessageSummary is
viewport-responsive, not container-responsive, so at lg+ it switches to a
horizontal list-row layout (fixed 200px photo + text beside it) that assumes
a full-width row. In a 15rem tile that left ~40px for the text, collapsing
the subject to zero width - a bare grey square. Widening can't fix it (the
subject needs ~292px; even a 512px card still clipped it), so keep the
vertical tile layout it already uses at md, and stack the type tag above the
title so the subject gets the full width.
Verified by posting through the real compose flow in a browser: state comes
back {ids:[9013], type:'Wanted'} and all four matches render with their full
subject, location, description and distance. Vitest 14273 pass.
freegleIt() now puts the posted ids in history state for both types, where it used to do so for Offers only. ids is shared with the Give flow's landing experience, but nothing asserted that the Offer-only donation ask keys off the type rather than the presence of ids - so a regression there would have been silent. Cover both directions: the ask shows after an Offer, and stays away after a Wanted even though ids now arrive for one.
…t button - Drop the "Good news -" prefix from the panel heading. - Give every match card the same height: the row stretches items to the tallest and the card's text area grows so the action pins to the bottom, aligning the buttons across the row. - Add an empty #footer slot to MessageSummary so callers can place actions inside the card; WantedMatches uses it for a centred "View post" button (opens the offer in a new tab, preserving the just-posted wanted).
edwh
added a commit
that referenced
this pull request
Jul 17, 2026
…-bounds schema TestMatchesReachFilter and TestSimilarReachFiltered (#953) insert rippling_reach rows without outer_bound; under the sandwich-bounds schema (#1098) that column is GEOMETRY NOT NULL with no possible default, so the INSERT fails, no reach row exists, and the out-of-reach candidate is never blocked. Seed the envelope of the fixture polygon as outer_bound (band semantics: the exact polygon still decides, so the tests' assertions are unchanged). #953 and #1098 crossed in flight - each was green before the other merged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZAyCpXy9jiHzQGi6jfzdJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 3 of the vector-search rollout: while someone composes a WANTED, show them existing OFFERs near them that match — so they can grab something that's already available instead of waiting.
GET /message/matches— given the item text of a wanted-in-progress and the poster's chosen location, returns existing OFFERs within ~15km that semantically match. Because the wanted isn't saved yet, this embeds the query text via the sidecar (unlike/similar, which reuses a stored embedding), then does the in-memory cosine scan filtered to Offers in a lat/lng box around the location. It applies a search-gradeMinVectorScore(this is a match claim, not exploration), reach-filters against the poster's chosen location (a matched offer they couldn't reply to is never shown — reusing Phase 2's sharedReachBlockedSet), and excludes the poster's own offers. Behind aFEATURE_WANTED_MATCH=offkillswitch.WantedMatches.vuerenders under the postcode chooser on both the desktop (find/whereami.vue) and mobile (find/mobile/whereami.vue) compose steps once a location is chosen. It's async, emits nothing that gates the Next button, and opens each match in a new tab (window.open(..., '_blank')) so the in-progress draft is never lost. A dismiss link ("Not what I'm looking for - keep posting") collapses it. Even one match is worth showing, so it appears with ≥1 result.wanted_match): impressions when the panel shows, clicks when a card is opened, and attributed replies within 7 days — all already visible on the ModTools "Recommendations" sysadmin tab.Failure-safety
The compose flow must never break because matching couldn't run. Every failure path (feature off, no location, empty query, sidecar/embedding unavailable, no matches) returns an empty list and the panel simply hides — there is no path that errors or blocks the poster.
Code Quality Review
(To be completed by the code-review pass before merge.) Reuses Phase 2's shared
ReachBlockedSetand theMessageSummarycard; the new-tab open via@expandavoids nested-anchor issues while preserving the draft; the route is registered before/message/:idsso "matches" isn't parsed as a message id.Future Improvements
Test Plan
TestMatches*(5): returns nearby same-type offers (excludes Wanteds and out-of-box offers), flag-off empty, no-location empty, excludes the caller's own offers, reach-filtered (out-of-reach dropped, no-reach-row kept).WantedMatches.spec.js6✓ (no render on 0 matches, renders + calls matches with query+location, impression counted once aswanted_match, opens in a new tab taggedwanted_match, dismiss hides, no fetch without a location). Full suite green.Rollback:
FEATURE_WANTED_MATCH=offdisables the panel server-side (no deploy).